Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [spearbit-98][quantstamp-8] State is not cached properly before validation/execution steps, Non-Idempotent Pre-Execution Hook #58

Merged
merged 19 commits into from
Jan 18, 2024

Conversation

adam-alchemy
Copy link
Contributor

@adam-alchemy adam-alchemy commented Jan 10, 2024

Motivation

https://github.com/spearbit-audits/alchemy-nov-review/issues/98

We are inconsistent in what account state we cache into memory prior to execution, and what we load from storage when needed.

Solution

Define distinct validation and execution phases, for which storage changes to account state should not result in different execution.

Update the implementation of UpgradeableModularAccount to cache according to these phases.

  • This is a very large re-organization of hook logic, because we previously mixed loading from storage and memory. This change isolates all storage loads first, then performs memory loads for executions as each one is needed.

Test the behavior of account state changes to ensure phases are preserved. This is a very tricky to orchestrate test since it involves re-entering the account from different plugin functions, and making assertions about the overall call flow. It also has a very large number of cases to test (89), so please read the comments in the AccountStatePhasesTest carefully.

Implementation strategy for hook caching

(Note the the extra slots for post-only hooks are only allocated if needed, otherwise they are not added to the length).

Regular (non-IPluginExecutor) call flow:

  • Load all pre exec hooks into memory
  • Allocate a bytes[] preExecReturnData of preExecHooksLength + 1 for holding the return data. (last element goes unused, to indicate no parameter)
  • Allocate a FunctionReference[][] postExecHooks of preExecHooksLength + 1 for holding the post-exec hooks.
  • Fill the last sub-array in postExecHooks with the post-only hooks.
  • Fill in each other sub-array with all associated post-exec hooks.
  • Run the pre exec hooks and put the values into preExecReturnData
  • Run the exec function
  • Run all postExecHooks, passing their return data from the correct array element in preExecReturnData.

Permitted call flow:

  • Load all pre permitted call hooks and pre exec hooks into memory
  • Allocate a bytes[] preHookReturnData of preExecHooksLength + prePermittedCallHooksLength + 2 for holding the return data. (Last 2 elements go unused)
  • Allocate a FunctionReference[][] postHooks of preExecHooksLength + prePermittedCallHooksLength + 2 for holding the post-* hooks.
  • Fill in the last sub-array with the post-only permitted call hooks.
  • Fill in the second to last sub-array with the post-only exec hooks.
  • Fill in each other sub-array with the associated post-permitted call hooks first, then the associated post-exec hooks.
  • Run the pre permitted call hooks and store the return data.
  • Run the pre exec hooks and store the return data.
  • Run the exec function.
  • Run all postExecHooks, passing their return data from the correct array element in preHookReturnData.

Code size

Code size is now 24464 bytes, leaving 112 bytes to spare.

Copy link
Collaborator

@jaypaik jaypaik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this lgtm, have to step out for a bit but will take another pass soon!

src/account/UpgradeableModularAccount.sol Show resolved Hide resolved
bytes4 selector = _selectorFromCallData(userOp.callData);
SelectorData storage selectorData = _getAccountStorage().selectorData[selector];

FunctionReference userOpValidationFunction = selectorData.userOpValidation;
hasPreValidationHooks = selectorData.hasPreUserOpValidationHooks;
bool hasPreValidationHooks = selectorData.hasPreUserOpValidationHooks;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

src/account/UpgradeableModularAccount.sol Outdated Show resolved Hide resolved
postHooksToRun = new FunctionReference[][](postHooksToRunLength);
postHookArgs = new bytes[](postHooksToRunLength);

uint256 currentIndex = 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
uint256 currentIndex = 0;
uint256 currentIndex;

postHooksToRun = new FunctionReference[][](postHooksToRunLength);
postHookArgs = new bytes[](postHooksToRunLength);

uint256 currentIndex = 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
uint256 currentIndex = 0;
uint256 currentIndex;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to keep these explicit for the reader, rather than having them thinking about the default values.

// We use `currentIndex + prePermittedCallHooks.length` for the starting index but do not update it,
// because its current value is useful for executing the hooks.
_cacheAssociatedPostHooks(
preExecHooks, selectorData.executionHooks, postHooksToRun, currentIndex + prePermittedCallHooks.length
Copy link
Collaborator

@jaypaik jaypaik Jan 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe store currentIndex + prePermittedCallHooks.length in a variable to reuse in line 694?

src/account/UpgradeableModularAccount.sol Outdated Show resolved Hide resolved
// in the args for their empty `bytes` argument.
uint256 postHooksToRunLength = preExecHooks.length + (hasPostOnlyExecHooks ? 1 : 0);
postHooksToRun = new FunctionReference[][](postHooksToRunLength);
postHookArgs = new bytes[](postHooksToRunLength);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not keep same indexing for postHooksToRun, postHookArgs, and preHooks, add postOnlyHooks in the end of postHooksToRun?

Save you the need of passing currentIndex.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the indexing wont match when the permitted call hooks are present. With such a change to hooks run rn, I am now more open to remove permitted call hooks.

src/account/UpgradeableModularAccount.sol Outdated Show resolved Hide resolved
// in the args for their empty `bytes` argument.
uint256 postHooksToRunLength = preExecHooks.length + (hasPostOnlyExecHooks ? 1 : 0);
postHooksToRun = new FunctionReference[][](postHooksToRunLength);
postHookArgs = new bytes[](postHooksToRunLength);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the indexing wont match when the permitted call hooks are present. With such a change to hooks run rn, I am now more open to remove permitted call hooks.

Copy link
Collaborator

@jaypaik jaypaik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough tests! Liking the consolidation of post hooks and post only hooks, along with the design of using a 2d array to address duplicate pre hooks that are associated with different post hooks.

Left some comments, mostly nits.

src/account/UpgradeableModularAccount.sol Outdated Show resolved Hide resolved
test/account/phases/AccountStatePhases.t.sol Outdated Show resolved Hide resolved
test/account/phases/AccountStatePhases.t.sol Outdated Show resolved Hide resolved
test/account/phases/AccountStatePhases.t.sol Outdated Show resolved Hide resolved
src/account/UpgradeableModularAccount.sol Outdated Show resolved Hide resolved
test/mocks/plugins/AccountStateMutatingPlugin.sol Outdated Show resolved Hide resolved
Comment on lines +97 to +115
// Source: pre-Exec
// Target: pre-UserOp-Validation
// n/a - runs before

// Source: pre-Exec
// Target: UserOp-Validation
// n/a - runs before

// Source: pre-Exec
// Target: pre-Runtime-Validation
// n/a - runs before

// Source: pre-Exec
// Target: Runtime-Validation
// n/a - runs before

// Source: pre-Exec
// Target: pre-Exec (same phase)
// Addition (first element): *impossible*
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: wasn't really clear why these were repeated (here and below) until I realized you're using these as placeholders for tests you are purposely skipping.

Comment on lines 655 to 675
if (hasPostOnlyPermittedCallHooks) {
// If we have post-only hooks, we allocate an single FunctionReference[] for them, and one element in
// the args for their empty `bytes` argument. We put this into the first element of the post hooks in
// order to have it run last.
postHooksToRun[currentIndex] =
CastLib.toFunctionReferenceArray(permittedCallData.permittedCallHooks.postOnlyHooks.getAll());
unchecked {
maxPostHooksToRunLength += preHookSet.getCount(CastLib.toSetValue(preExecHooks[i]));
++i;
++currentIndex;
}
}

if (hasPostOnlyExecHooks) {
// If we have post-only hooks, we allocate an single FunctionReference[] for them, and one element in
// the args for their empty `bytes` argument. We put this into the first element of the post hooks in
// order to have it run last.
postHooksToRun[currentIndex] =
CastLib.toFunctionReferenceArray(selectorData.executionHooks.postOnlyHooks.getAll());
unchecked {
++currentIndex;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic will run the different hooks in this order:

  1. Associated post hooks for selectorData.executionHooks
  2. Associated post hooks for permittedCallData.permittedCallHooks
  3. Post only hooks for selectorData.executionHooks
  4. Post only hooks for permittedCallData.permittedCallHooks

Technically, I think the order before was 1, 3, 2, 4. For example see this code from before:

// Run any post exec hooks for the `executeFromPluginExternal` selector
_doCachedPostHooks(postExecHooks, postExecHookArgs);

// Run any post only exec hooks for the `executeFromPluginExternal` selector
_doCachedPostHooks(
    CastLib.toFunctionReferenceArray(
        storage_.selectorData[IPluginExecutor.executeFromPluginExternal.selector]
            .executionHooks
            .postOnlyHooks
            .getAll()
    ),
    new bytes[](0)
);

// Run any post permitted call hooks specific to this caller and the `executeFromPluginExternal` selector
_doCachedPostHooks(postPermittedCallHooks, postPermittedCallHookArgs);

// Run any post only permitted call hooks specific to this caller and the `executeFromPluginExternal`
// selector
_doCachedPostHooks(
    CastLib.toFunctionReferenceArray(
        storage_.permittedCalls[permittedCallKey].permittedCallHooks.postOnlyHooks.getAll()
    ),
    new bytes[](0)
);

I think either way is okay, but is this an intentional change?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is probably worth fixing. We'll need to keep track of the two indices that point to the beginning of each type of associated hook but seems like an easy fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed this to run in the post hooks in the following order:

  1. associated postExec
  2. post-only postExec
  3. associated postPermittedCall
  4. post-only postPermittedCall

In this commit: bae9391

Could you please double check? I walked through with a tracer but wanted to confirm.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that permitted call hooks are being removed (basing this off of PR 65). Will this PR need to be updated as well (maybe after merging PR 65)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 65 will be rebased on top of this and merged later. So I guess we don't strictly need to review this, since the invocation of _doPrePermittedCallAndPreExecHooks will be replaced with _doPreExecHooks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New PR addressing this: #76 (#65 has been closed).

@adam-alchemy adam-alchemy merged commit 28c7bf1 into audit-2023-11-20 Jan 18, 2024
3 checks passed
@adam-alchemy adam-alchemy deleted the adam/fix-98 branch January 18, 2024 16:36
adam-alchemy added a commit that referenced this pull request Jan 19, 2024
…validation/execution steps, Non-Idempotent Pre-Execution Hook (#58)
jaypaik pushed a commit that referenced this pull request Jan 25, 2024
…validation/execution steps, Non-Idempotent Pre-Execution Hook (#58)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants